Go言語で基本的なCRUD操作を行うREST APIを作成
はじめに
Javaのエンジニアだった私がGo言語でREST APIを作る上で学んだことをまとめています。
プロジェクト構成、単体テスト、Dockerイメージの作成など実際にREST APIを開発する上で必要だと思われる要素を盛り込みつつサンプルプロジェクトを作成していきます。
今回はできるだけ外部ライブラリやフレームワークを使わずにGo言語の標準機能のみで開発しました。
これからバックエンドにGo言語を使用することを検討されている方の参考になれば幸いです。
※この記事は既にGo言語の開発環境をセットアップ済みで基本的な文法を学習済みの方を想定しています。
動作環境
今回使用した動作環境は以下のとおりです。
- PC : Mac M1(Apple Silicon)チップ
- OS : macOS Big Sir 11.5.2
- Go : 1.17.1
- Docker Desktop : 4.0.0 Engine : 20.10.8
- MySQL : 8.0.25
プロジェクト構成
ネット上で見るGo言語のプロジェクト構成はクリーンアーキテクチャの影響を受けているものが多い印象ですが、今回は慣れ親しんだMVCアーキテクチャ風の構成にしました。
sample-api/ ルートディレクトリ ┣ build/ Dockerfileなど ┃ ┣ db/ 動作確認用DB ┃ ┃ ┣ sql/ DDLとテストデータ投入用SQL ┃ ┃ ┗ Dockerfile ┃ ┣ sample-api/ ┃ ┃ ┗ Dockerfile このサンプルプロジェクトの実行ファイルを含んだイメージを作成するためのDockerfile ┃ ┗ docker-compose.yml ┣ cmd/ ┃ ┗ sample-api ┃ ┗ main.go メイン処理 ┣ controller/ ┃ ┣ dto/ リクエスト/レスポンス用のDTOファイルを配置する ┃ ┣ router.go HTTPメソッドを元にコントローラの各処理へのルーティングを行う ┃ ┣ router_test.go `router.go`のテストファイル ┃ ┣ todo_controller.go リクエストを元にモデルの各処理を呼び出しレスポンスを返却する ┃ ┗ todo_controller_test.go `todo_controller.go`のテストファイル ┣ model/ ┃ ┣ entity/ ┃ ┗ repository/ ┣ test/ ┗ mock.go 単体テスト用のモック ┣ test_results/ 単体テストのカバレッジファイルを配置する ┣ Makefile ┣ go.mod ┗ go.sum
個人的にプロジェクト構成でポイントだと感じたのは以下の3点です。
- メイン処理を持ったコードは
cmd
フォルダ配下に実行可能ファイルの名前と一致したフォルダを作成し配置するのが一般的 - Javaを経験していると
src
フォルダを作りたくなるが、Go言語ではGOPATH
配下に置かれるsrc
フォルダと混乱をきたすため作成すべきではない - テストコードはテスト対象ファイルと同じフォルダ階層に配置するのが基本(テスト対象ファイルの公開されていない変数/関数にアクセスできるため)
Standard Go Project LayoutというGo言語でのメジャーなプロジェクトルールもありますが、規模の小さいプロジェクトだとやりすぎな感があるので、やはりプロジェクトの開発規模にあった構成を各自検討する必要がありそうです。
その他プロジェクト構成については以下の記事が勉強になりました。
サンプルプロジェクト
このサンプルプロジェクトは基本的なCRUD操作を行うREST APIです。TODOアプリのバックエンドのイメージで、TODO(タイトルと内容)の取得/追加/更新/削除が行えます。
ここでは一部コードを抜粋して説明していきます。全ファイルは私のGithubリポジトリをご参照ください。
package main import ( "net/http" "github.com/koga456/sample-api/controller" "github.com/koga456/sample-api/model/repository" ) var tr = repository.NewTodoRepository() var tc = controller.NewTodoController(tr) var ro = controller.NewRouter(tc) func main() { server := http.Server{ Addr: ":8080", } http.HandleFunc("/todos/", ro.HandleTodosRequest) server.ListenAndServe() }
- 10~12行目はコンストラクタインジェクションでDIを行っています。
- 16行目はサーバが起動するポート番号を設定しています。この設定の場合、
localhost:8080
で起動します。 - 18行目は
localhost:8080/todos/
に届いたリクエストをHandleTodosRequest
で処理するように設定しています。 - 19行目で実際にサーバが起動します。
Go言語のHTTPサーバとDIについては以下の記事が勉強になりました。
package repository import ( "database/sql" "fmt" ) var Db *sql.DB func init() { var err error dataSourceName := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8", "todo-app", "todo-password", "sample-api-db:3306", "todo", ) Db, err = sql.Open("mysql", dataSourceName) if err != nil { panic(err) } }
init()
はパッケージの初期化処理などに使われます。このサンプルプロジェクトの場合github.com/koga456/sample-api/model/repository
がimportされたタイミングで動作し、main.go
のメイン処理より先に実行されます。- DSNは
[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN]
の仕様に従って設定します。ここで設定しているusername
、password
、address
、dbname
は動作確認用DBのdocker-compose.yml
とDockerfile
の値を設定しています。またaddress
は今回はコンテナ同士の通信になるので動作確認用DBのコンテナ名を設定します。その他のパラメータについてはgithub.com/go-sql-driver/mysqlをご参照ください。 - 15行目のようにドライバとDSNを指定するとDBのコネクションを取得できます。
package controller import ( "net/http" ) // 外部パッケージに公開するインタフェース type Router interface { HandleTodosRequest(w http.ResponseWriter, r *http.Request) } // 非公開のRouter構造体 type router struct { tc TodoController } // Routerのコンストラクタ。引数にTodoControllerを受け取り、Router構造体のポインタを返却する。 func NewRouter(tc TodoController) Router { return &router{tc} } func (ro *router) HandleTodosRequest(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": ro.tc.GetTodos(w, r) case "POST": ro.tc.PostTodo(w, r) case "PUT": ro.tc.PutTodo(w, r) case "DELETE": ro.tc.DeleteTodo(w, r) default: w.WriteHeader(405) } }
- HTTPメソッドを元にコントローラの各処理を呼び出すハンドラ関数です。不正なHTTPメソッドの場合は、405エラーを返却します。
以下はTODOのテーブル定義と投入するテストデータです。
CREATE TABLE IF NOT EXISTS todo ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, title VARCHAR(40) NOT NULL, content VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, updated_at TIMESTAMP NOT NULL DEFAULT current_timestamp ON UPDATE current_timestamp )
INSERT INTO todo (title, content) VALUES ('買い物', '今日の帰りに夕食の材料を買う'); INSERT INTO todo (title, content) VALUES ('勉強', 'TOEICの勉強を1時間やる'); INSERT INTO todo (title, content) VALUES ('ゴミ出し', '次の火曜日は燃えないゴミの日なので忘れないように');
以下は上記のテーブル定義を元に作成したDTOとEntityファイルです。
package dto type TodoResponse struct { Id int `json:"id"` Title string `json:"title"` Content string `json:"content"` } type TodoRequest struct { Title string `json:"title"` Content string `json:"content"` } type TodosResponse struct { Todos []TodoResponse `json:"todos"` }
- JSONデコード/エンコード用のDTOは構造体で定義します。構造体の各フィールドの末尾
json:"XXX"
のXXXがJSONのフィールド名になります。
package entity type TodoEntity struct { Id int Title string Content string }
以下が実際にCRUD処理を行うコントローラとリポジトリです。
package controller import ( "encoding/json" "net/http" "path" "strconv" "github.com/koga456/sample-api/controller/dto" "github.com/koga456/sample-api/model/entity" "github.com/koga456/sample-api/model/repository" ) // 外部パッケージに公開するインタフェース type TodoController interface { GetTodos(w http.ResponseWriter, r *http.Request) PostTodo(w http.ResponseWriter, r *http.Request) PutTodo(w http.ResponseWriter, r *http.Request) DeleteTodo(w http.ResponseWriter, r *http.Request) } // 非公開のTodoController構造体 type todoController struct { tr repository.TodoRepository } // TodoControllerのコンストラクタ。 // 引数にTodoRepositoryを受け取り、TodoController構造体のポインタを返却する。 func NewTodoController(tr repository.TodoRepository) TodoController { return &todoController{tr} } // TODOの取得 func (tc *todoController) GetTodos(w http.ResponseWriter, r *http.Request) { // リポジトリの取得処理呼び出し todos, err := tc.tr.GetTodos() if err != nil { w.WriteHeader(500) return } // 取得したTODOのentityをDTOに詰め替え var todoResponses []dto.TodoResponse for _, v := range todos { todoResponses = append(todoResponses, dto.TodoResponse{Id: v.Id, Title: v.Title, Content: v.Content}) } var todosResponse dto.TodosResponse todosResponse.Todos = todoResponses // JSONに変換 output, _ := json.MarshalIndent(todosResponse.Todos, "", "\t\t") // JSONを返却 w.Header().Set("Content-Type", "application/json") w.Write(output) } // TODOの追加 func (tc *todoController) PostTodo(w http.ResponseWriter, r *http.Request) { // リクエストbodyのJSONをDTOにマッピング body := make([]byte, r.ContentLength) r.Body.Read(body) var todoRequest dto.TodoRequest json.Unmarshal(body, &todoRequest) // DTOをTODOのEntityに変換 todo := entity.TodoEntity{Title: todoRequest.Title, Content: todoRequest.Content} // リポジトリの追加処理呼び出し id, err := tc.tr.InsertTodo(todo) if err != nil { w.WriteHeader(500) return } // LocationにリソースのPATHを設定し、ステータスコード201を返却 w.Header().Set("Location", r.Host+r.URL.Path+strconv.Itoa(id)) w.WriteHeader(201) } // TODOの更新 func (tc *todoController) PutTodo(w http.ResponseWriter, r *http.Request) { // URLのPATHに含まれるTODOのIDを取得 todoId, err := strconv.Atoi(path.Base(r.URL.Path)) if err != nil { w.WriteHeader(400) return } // リクエストbodyのJSONをDTOにマッピング body := make([]byte, r.ContentLength) r.Body.Read(body) var todoRequest dto.TodoRequest json.Unmarshal(body, &todoRequest) // DTOをTODOのEntityに変換 todo := entity.TodoEntity{Id: todoId, Title: todoRequest.Title, Content: todoRequest.Content} // リポジトリの更新処理呼び出し err = tc.tr.UpdateTodo(todo) if err != nil { w.WriteHeader(500) return } // ステータスコード204を返却 w.WriteHeader(204) } // TODOの削除 func (tc *todoController) DeleteTodo(w http.ResponseWriter, r *http.Request) { // URLのPATHに含まれるTODOのIDを取得 todoId, err := strconv.Atoi(path.Base(r.URL.Path)) if err != nil { w.WriteHeader(400) return } // リポジトリの削除処理呼び出し err = tc.tr.DeleteTodo(todoId) if err != nil { w.WriteHeader(500) return } // ステータスコード204を返却 w.WriteHeader(204) }
package repository import ( "log" _ "github.com/go-sql-driver/mysql" "github.com/koga456/sample-api/model/entity" ) // 外部パッケージに公開するインタフェース type TodoRepository interface { GetTodos() (todos []entity.TodoEntity, err error) InsertTodo(todo entity.TodoEntity) (id int, err error) UpdateTodo(todo entity.TodoEntity) (err error) DeleteTodo(id int) (err error) } // 非公開のTodoRepository構造体 type todoRepository struct { } // TodoRepositoryのコンストラクタ。TodoRepository構造体のポインタを返却する。 func NewTodoRepository() TodoRepository { return &todoRepository{} } // TODO取得処理 func (tr *todoRepository) GetTodos() (todos []entity.TodoEntity, err error) { todos = []entity.TodoEntity{} // DBから全てのTODOを取得 rows, err := Db. Query("SELECT id, title, content FROM todo ORDER BY id DESC") if err != nil { log.Print(err) return } // 1行ごとTODOのEntityにマッピングし、返却用のスライスに追加 for rows.Next() { todo := entity.TodoEntity{} err = rows.Scan(&todo.Id, &todo.Title, &todo.Content) if err != nil { log.Print(err) return } todos = append(todos, todo) } return } // TODO追加処理 func (tr *todoRepository) InsertTodo(todo entity.TodoEntity) (id int, err error) { // 引数で受け取ったEntityの値を元にDBに追加 _, err = Db.Exec("INSERT INTO todo (title, content) VALUES (?, ?)", todo.Title, todo.Content) if err != nil { log.Print(err) return } // created_atが最新のTODOのIDを返却 err = Db.QueryRow("SELECT id FROM todo ORDER BY id DESC LIMIT 1").Scan(&id) return } // TODO更新処理 func (tr *todoRepository) UpdateTodo(todo entity.TodoEntity) (err error) { // 引数で受け取ったEntityの値を元にDBを更新 _, err = Db.Exec("UPDATE todo SET title = ?, content = ? WHERE id = ?", todo.Title, todo.Content, todo.Id) return } // TODO削除処理 func (tr *todoRepository) DeleteTodo(id int) (err error) { // 引数で受け取ったIDの値を元にDBから削除 _, err = Db.Exec("DELETE FROM todo WHERE id = ?", id) return }
その他Go言語のDB/SQL関連については以下の記事が勉強になりました。
FROM golang:1.17.1-alpine as builder WORKDIR /build COPY ../../go.mod ../../go.sum ./ RUN go mod download COPY ../../ ./ ARG CGO_ENABLED=0 ARG GOOS=linux ARG GOARCH=amd64 RUN go build -ldflags '-s -w' ./cmd/sample-api FROM alpine COPY --from=builder /build/sample-api /opt/app/ ENTRYPOINT ["/opt/app/sample-api"]
Dockerfileに関しては以下の記事を参考にさせて頂きました。
format: @find . -print | grep --regex '.*\.go' | xargs goimports -w -local "github.com/koga456/sample-api" verify: @staticcheck ./... && go vet ./... unit-test: @go test ./... -coverprofile=./test_results/cover.out && go tool cover -html=./test_results/cover.out -o ./test_results/cover.html serve: @docker-compose -f build/docker-compose.yml up
フォーマット、静的解析、単体テスト、起動の4つのコマンドを定義しています。
実行方法
Githubリポジトリからこのサンプルプロジェクトをダウンロード後任意のディレクトリに配置し、ルートディレクトリで下記コマンドを実行してください。
Makefileに記載されたコマンドが実行され、ビルドされた実行ファイルを含むDockerイメージを作成後、動作確認用DBと共にコンテナとして起動します。
% make serve
フォアグラウンド処理となるので停止したい場合は、controlキー + c
を押してください。
別のターミナルを立ち上げcurl
コマンドを叩くと下記のようにTODOに対するCRUD操作が行えます。
[注意]もしM1チップ以外のMacで実行する場合は、下記ファイルの--platform=linux/amd64
の部分を削除してください。
FROM --platform=linux/amd64 library/mysql:8.0.25 ENV MYSQL_DATABASE todo COPY custom.cnf /etc/mysql/conf.d/ COPY sql /docker-entrypoint-initdb.d
TODO取得
% curl -i localhost:8080/todos/ Content-Type: application/json Content-Length: 346 [ { "id": 3, "title": "ゴミ出し", "content": "次の火曜日は燃えないゴミの日なので忘れないように" }, { "id": 2, "title": "勉強", "content": "TOEICの勉強を1時間やる" }, { "id": 1, "title": "買い物", "content": "今日の帰りに夕食の材料を買う" } ]
TODO追加
% curl -i -X POST -H "Content-Type: application/json" -d '{"title":"test", "content":"テストです。"}' localhost:8080/todos/ HTTP/1.1 201 Created Location: localhost:8080/todos/4 Content-Length: 0 % curl -i localhost:8080/todos/ Content-Type: application/json Content-Length: 425 [ { "id": 4, "title": "test", "content": "テストです。" }, { "id": 3, "title": "ゴミ出し", "content": "次の火曜日は燃えないゴミの日なので忘れないように" }, { "id": 2, "title": "勉強", "content": "TOEICの勉強を1時間やる" }, { "id": 1, "title": "買い物", "content": "今日の帰りに夕食の材料を買う" } ]
TODO更新
% curl -i -X PUT -H "Content-Type: application/json" -d '{"title":"test", "content":"変更テスト"}' localhost:8080/todos/4 HTTP/1.1 204 No Content % curl -i localhost:8080/todos/ Content-Type: application/json Content-Length: 422 [ { "id": 4, "title": "test", "content": "変更テスト" }, { "id": 3, "title": "ゴミ出し", "content": "次の火曜日は燃えないゴミの日なので忘れないように" }, { "id": 2, "title": "勉強", "content": "TOEICの勉強を1時間やる" }, { "id": 1, "title": "買い物", "content": "今日の帰りに夕食の材料を買う" } ]
TODO削除
% curl -i -X DELETE localhost:8080/todos/4 HTTP/1.1 204 No Content % curl -i localhost:8080/todos/ Content-Type: application/json Content-Length: 346 [ { "id": 3, "title": "ゴミ出し", "content": "次の火曜日は燃えないゴミの日なので忘れないように" }, { "id": 2, "title": "勉強", "content": "TOEICの勉強を1時間やる" }, { "id": 1, "title": "買い物", "content": "今日の帰りに夕食の材料を買う" } ]
単体テストについて
サンプルプロジェクトで作成した単体テストについて説明していきます。
controller
パッケージに関してはカバレッジ100%を満たすように単体テストを作成していますが、ここでは一番シンプルなrouter.go
の単体テストを一部抜粋します。
テスト対象
type Router interface { HandleTodosRequest(w http.ResponseWriter, r *http.Request) } type router struct { tc TodoController } func NewRouter(tc TodoController) Router { return &router{tc} } func (ro *router) HandleTodosRequest(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": ro.tc.GetTodos(w, r) case "POST": ro.tc.PostTodo(w, r) case "PUT": ro.tc.PutTodo(w, r) case "DELETE": ro.tc.DeleteTodo(w, r) default: w.WriteHeader(405) } }
テスト用モック
package test import ( "errors" "net/http" "github.com/koga456/sample-api/model/entity" ) type MockTodoController struct { } func (mtc *MockTodoController) GetTodos(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) } func (mtc *MockTodoController) PostTodo(w http.ResponseWriter, r *http.Request) { w.WriteHeader(201) } func (mtc *MockTodoController) PutTodo(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } func (mtc *MockTodoController) DeleteTodo(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
MockTodoController
はTodoController
のインタフェースに定義された関数を全て実装しています。処理はhttp.ResponseWriter
にステータスコードを設定するのみです。
テストファイル
package controller import ( "net/http" "net/http/httptest" "os" "strings" "testing" // Go言語標準のテスト用パッケージです。 "github.com/koga456/sample-api/test" ) // URLとハンドラ関数を関連付けるマルチプレクサと呼ばれる構造体。 // 複数のテストで共通して使うのでパッケージ変数として定義しています。 var mux *http.ServeMux // 前/後処理のようなテストのフロー制御を行うための関数です。 // 前/後処理を行う必要がない場合は不要です。 func TestMain(m *testing.M) { setUp() // 各テストケースを実行します。今回だと`TestGetTodos`と`TestPostTodo`です。 code := m.Run() os.Exit(code) } // 前処理用の関数です。関数名は他の名前でも問題ありません。 func setUp() { // テスト用のモックをDIし`Router`のポインタを取得しています。 // `MockTodoController`は`TodoController`インタフェースの関数を全て実装しているのでDI可能です。 target := NewRouter(&test.MockTodoController{}) // テストを実行するマルチプレクサを生成 mux = http.NewServeMux() // マルチプレクサにURLとテスト対象のハンドラ関数を関連付けます。 mux.HandleFunc("/todos/", target.HandleTodosRequest) } func TestGetTodos(t *testing.T) { // Goのテスト関数は`*testing.T`を引数に受け取ります。 // リクエストの生成 r, _ := http.NewRequest("GET", "/todos/", nil) // レスポンスを取得するための処理 w := httptest.NewRecorder() // テスト対象のハンドラ関数にリクエストを送信 mux.ServeHTTP(w, r) // `MockTodoController`で設定しているステータスコード200が設定されていることを確認します。 if w.Code != 200 { // ステータスコードが200以外が設定されている場合、 // テスト失敗なのでエラーを出力(後続のテストは継続される) t.Errorf("Response cod is %v", w.Code) } } func TestPostTodo(t *testing.T) { // bodyにJSONを設定したリクエストの生成 json := strings.NewReader(`{"title":"test-title","content":"test-content"}`) r, _ := http.NewRequest("POST", "/todos/", json) w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != 201 { t.Errorf("Response cod is %v", w.Code) } }
テストの実行方法とカバレッジの出し方
テストは下記コマンドで実行します。
% go test # カレントディレクトリのファイルを対象に実行 % go test ./.. # カレントディレクトリ配下の全てのファイルを対象に実行
下記のオプションを指定するとカバレッジが出力されます。
% go test -cover
さらに下記コマンドを実行するとより詳細なカバレッジが出力されます。
% go test -coverprofile
カバレッジをファイルに出力後、html形式で確認することもできます。
% go test -coverprofile=cover.out % go tool cover -html=cover.out -o cover.html % open cover.html
このプロジェクトでの単体テストの実行方法
Makefileにコマンドを定義しているので、ルートディレクトリで下記コマンドを実行すると全てのテストを実行され、test_results
フォルダ配下にhtml形式のカバレッジ測定結果が出力されます。
% make unit-test
最後に
標準機能のみの少ないコード量で複雑な設定ファイルなどもなく、簡単にAPIサーバが立ち上げれるのはGo言語の魅力の1つだと改めて思いました。
今回は標準機能のみで開発しましたが、マルチプレクサやハンドラ関数でのルーティング、DI、mock、テストでのassertなど標準機能のみだと少し辛いなと感じる部分もあったので、そのあたりを次回以降外部ライブラリなどを使い改善していきたいです。